Skip to content

fix(runtime): present preserves the display aspect instead of pillarboxing narrow buffers - #177

Open
smmathews wants to merge 1 commit into
ran-j:mainfrom
smmathews:feature/17-present-fill-window
Open

fix(runtime): present preserves the display aspect instead of pillarboxing narrow buffers#177
smmathews wants to merge 1 commit into
ran-j:mainfrom
smmathews:feature/17-present-fill-window

Conversation

@smmathews

@smmathews smmathews commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

PS2Runtime::run()'s present step built its destination rectangle from the decoded
buffer's own pixel dimensions: it scaled by
min(screenWidth / srcWidth, screenHeight / srcHeight) and centred the result. For a
buffer as wide as the window that is a no-op, but any display buffer narrower than
the window was shrunk to its own column count and padded with black down both sides.

A title programming a 512-column display buffer rendered correct content, pillarboxed,
in the 640-wide window — where a 640-column buffer already filled it.

Fix

The buffer's column count is not the display's width. The GS scans a DISPLAY/DISPFB
buffer of any column count across the full display region, so a 512-column buffer is the
same full-screen picture as a 640-column one, produced from fewer columns.

computePresentDstRect(dispWidth, dispHeight, screenWidth, screenHeight) now fits the
display regionFB_WIDTH at the current decoded height — into the window,
preserving that region's aspect and centring it. The source rectangle still samples the
decoded buffer, so a narrow buffer is stretched across the region and fills the window
exactly as a full-width one does. UploadFrame's decoding and the source rectangle are
unchanged; only the destination rectangle moves.

The helper lives in ps2xRuntime/include/runtime/present_layout.h, which includes only
<algorithm> — no windowing or graphics type — so the geometry is testable without a
live presentation window.

No title check and no per-game dimension: one destination-rectangle computation that runs
identically for every frame.

Basis

The GS scans a display buffer across the full display region regardless of its column
count. Treating the column count as the picture's on-screen width is what pillarboxed
narrow buffers.

Testing

PresentLayout suite, in ps2xTest/src/present_layout_tests.cpp:

  • a window matching the display aspect is filled exactly
  • a wider window pillarboxes, a taller window letterboxes, neither stretches
  • the preserved aspect is the display region's, not the window's
  • a degenerate display width, and a degenerate display height, each fall back to filling
    the window

Build and run:

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/ps2xTest/ps2x_tests
Mutation evidence: which mutation falsifies which property

Each is a one-line change to computePresentDstRect, applied alone.

Mutation Fails
return {0, 0, screenWidth, screenHeight} unconditionally (pure window fill) pillarbox, letterbox, aspect-follows-the-region. The matching-aspect fill still passes, and both degenerate cases still pass — fallback and fill agree there
guard returns {0, 0, 0, 0} both degenerate cases
guard tests only the width axis the degenerate-height case alone

The first row is why the degenerate cases need their own guard mutation: a pure-fill
mutation cannot distinguish them.

Risk and not in scope

  • Full-width buffers at the native window size are unchanged. Fill and aspect-fit
    agree when the window already matches the display aspect (640x448).
  • When the window's shape differs from the display's — a resized desktop window, or
    the fixed 960x544 Vita window against a 640x448 region — the region is letterboxed or
    pillarboxed rather than stretched, so the picture keeps its aspect on every platform.
    An earlier iteration that simply filled the raw window horizontally stretched 640x448
    content on the Vita screen; preserving the display aspect avoids that.
  • Manual check: a title whose decoded buffer is narrower than the window renders
    edge-to-edge instead of inset with black bars. That observation is not reproducible
    from this repository.

…oxing narrow buffers

The present step scaled the guest's decoded display buffer to fit the host
window at its native 1:1 pixel aspect and centered it. A buffer as wide as the
window was a no-op, but a narrower buffer -- e.g. a 512-column DISPLAY/DISPFB
buffer in the 640-wide window -- was shrunk to its own column count and padded
with black side bars instead of filling the display the way a full-width buffer
does. The buffer's column count is not the display's width: the GS scans any
column count across the full display region, so the picture should occupy the
whole display.

Drive the destination rectangle from the intended display region -- the full
display width at the current decoded height -- rather than the buffer's column
count, via computePresentDstRect(), which fits that region into the window
preserving its aspect and centering it. A narrow buffer now fills the display
region, and the window at native size, exactly as a full-width buffer does; when
the window's shape differs from the display's (a resized desktop window, or the
960x544 Vita window versus the 640x448 region) the region is letterboxed rather
than stretched, so the picture keeps its aspect on every platform. The source
rectangle and UploadFrame's decoding are unchanged. The geometry is a graphics-
type-free header so it can be unit-tested without a live presentation window.
@smmathews
smmathews marked this pull request as ready for review July 22, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant